Skip to content

Upgrade a2a to spec v0.2.3 #2144

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 27 commits into
base: main
Choose a base branch
from

Conversation

physicsrob
Copy link

Summary

This PR upgrades the fasta2a implementation from A2A protocol v0.1 to v0.2.3. The upgrade introduces significant improvements to conversation continuity through the new A2A context_id concept and implements a dual-purpose storage architecture for efficient multi-turn conversations.

Note: This is a breaking change and is not backward compatible. However, given the limited adoption of the previous protocol version, this change prioritizes alignment with the current A2A specification.

Key Changes

Protocol Updates

  • Renamed endpoint from tasks/send to message/send
  • Replaced type fields with kind throughout the schema
  • Switched from client-generated to server-generated task IDs
  • Updated Message and Task structures to v0.2.3 format
  • Replaced session_id with context_id for conversation continuity and adherence to A2A spec

Conversation Model Enhancements

  • Context-based conversations: Introduced context_id to group related messages across multiple tasks
  • Dual-purpose storage:
    • Task storage maintains A2A protocol compliance
    • Context storage preserves rich agent state (tool calls, thinking, etc.)
  • Conversation continuity: Multi-turn conversations now span multiple task executions
  • Storage API improvements:
    • Added update_context() and get_context() methods for agent-specific state
    • Modified update_task() to accept new_messages and new_artifacts lists
    • Removed deprecated message parameter from update_task()

Schema Enhancements

  • Added id field to PushNotificationConfig for server-assigned identifiers
  • Added new task states: rejected and auth-required
  • Implemented proper file handling with FileWithBytes and FileWithUri
  • Added type guards (is_task(), is_message()) for better type safety

Artifact Improvements

  • Support for both TextPart and DataPart in artifacts based on output type
  • Added unique artifact IDs for better tracking
  • Dual output approach:
    • Agent results stored as Messages (for conversation history)
    • Agent results stored as Artifacts (for durable outputs)
  • Enhanced metadata including JSON schema for Pydantic models

AgentWorker Implementation

  • Loads full pydantic-ai message history from context storage
  • Preserves complete conversation state including tool calls and responses
  • Converts between A2A and pydantic-ai message formats as needed
  • Ensures task state validation (only processes 'submitted' tasks)

Test Changes

  • Updated all tests to work with the new protocol format
  • Added test for Pydantic model outputs with JSON schema metadata
  • Added test for monotonic message history growth across contexts
  • Renamed test directory from tests/fasta2a/ to tests/test_fasta2a/ to avoid import conflicts

Breaking Changes

  1. Storage API changes:

    • New required methods: update_context() and get_context()
    • Task submission now requires context_id
    • update_task() signature changed
  2. Message format changes:

    • typekind throughout
    • session_idcontext_id
    • New required fields in various message types
  3. Endpoint changes:

    • tasks/sendmessage/send

- Update protocol methods: tasks/send → message/send
- Replace 'type' with 'kind' throughout schema
- Replace 'session_id' with 'context_id' for conversation tracking
- Add Message and Part types (TextPart, FilePart, DataPart)
- Implement dual message/artifact approach for agent outputs
- Add metadata to artifacts including type info and JSON schema
- Add proper error handling with task state updates
- Add NotImplementedError stubs for streaming methods
- Rename test directory to avoid import conflicts
- Test that Pydantic model outputs are correctly serialized as DataPart
- Verify metadata includes type name and JSON schema
- Ensure dual message/artifact approach works for complex types
- Confirm that both message history and artifacts contain the data
- Add update_context() and get_context() methods to Storage
- Store full pydantic-ai message history (including tool calls) in context
- Preserve conversation state across multiple tasks with same context_id
- Update docs to explain task vs context distinction
- Add test for monotonic message history growth
- Clean up run_task: remove history_length, add state check, fix comments
Copy link
Contributor

hyperlint-ai bot commented Jul 7, 2025

PR Change Summary

Upgraded the fasta2a implementation to A2A protocol v0.2.3, introducing significant enhancements for conversation continuity and a dual-purpose storage architecture.

  • Renamed endpoint from tasks/send to message/send and replaced type fields with kind in the schema.
  • Introduced context_id for improved conversation continuity and updated storage architecture for multi-turn conversations.
  • Implemented new methods in the Storage API and made breaking changes to message formats and task submissions.

Modified Files

  • docs/a2a.md

How can I customize these reviews?

Check out the Hyperlint AI Reviewer docs for more information on how to customize the review.

If you just want to ignore it on this PR, you can add the hyperlint-ignore label to the PR. Future changes won't trigger a Hyperlint review.

Note specifically for link checks, we only check the first 30 links in a file and we cache the results for several hours (for instance, if you just added a page, you might experience this). Our recommendation is to add hyperlint-ignore to the PR to ignore the link check for this PR.

@physicsrob
Copy link
Author

@Kludex
I noticed your message "Working on this" -- just FYI I spend a bunch of time on yesterday working on pulling out just the upgrade. Here's a draft PR:.

I was going to spend a couple more hours cleaning up before submitting a PR, but it's fairly close. I did end up changing the approach pretty significantly to conversation continuity. There was a pretty big fundamental limitation in the previous incarnation: Since only A2A messages were persisted / retrieved that meant that follow-up tasks could only see messages that were converted to/from A2A's format, and that's fundamentally going to be lossy. In particular tool call results from previous tasks are going to be invisible to the agent for subsequent calls. I personally think that's a big limitation worth addressing.

The one area I'm very much less convinced of my approach: Whether final results should be an artifact or both an artifact and a message. Right now in the PR it generates both. But I was probably going to make it just an artifact

Comment on lines 128 to 130
elif a2a_request['method'] == 'tasks/send': # type: ignore[comparison-overlap]
# Legacy method - no longer supported
raise NotImplementedError('tasks/send is deprecated. Use message/send instead.')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need for this. Just drop it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


payload = SendTaskRequest(jsonrpc='2.0', id=None, method='tasks/send', params=task)
content = a2a_request_ta.dump_json(payload, by_alias=True)
request_id = str(uuid.uuid4())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this ID? Is it the request_id?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is the request_id for JSON-RPC. Let me know if you think a clarifying comment would be helpful, or if there's something off about the variable names

Comment on lines 315 to 316
description: NotRequired[str]
"""A description of the data."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any description on the DataPart on the specification.

Suggested change
description: NotRequired[str]
"""A description of the data."""

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah not sure how that ended up there. Fixed. thx.

"""A fully formed piece of content exchanged between a client and a remote agent as part of a Message or an Artifact.
Each Part has its own content type and metadata.
"""

TaskState: TypeAlias = Literal['submitted', 'working', 'input-required', 'completed', 'canceled', 'failed', 'unknown']
TaskState: TypeAlias = Literal[
'submitted', 'working', 'input-required', 'completed', 'canceled', 'failed', 'rejected', 'auth-required'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unknown is still in the specification.

Suggested change
'submitted', 'working', 'input-required', 'completed', 'canceled', 'failed', 'rejected', 'auth-required'
'submitted', 'working', 'input-required', 'completed', 'canceled', 'failed', 'rejected', 'auth-required', 'unknown'

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines 650 to 657
def is_task(response: Task | Message) -> TypeGuard[Task]:
"""Type guard to check if a response is a Task."""
return 'id' in response and 'status' in response and 'context_id' in response and response.get('kind') == 'task'


def is_message(response: Task | Message) -> TypeGuard[Message]:
"""Type guard to check if a response is a Message."""
return 'role' in response and 'parts' in response and response.get('kind') == 'message'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be for the tests. There's no need to include those in the schema.

Also, on the tests, just check the kind is task or message. It's enough to infer the type for the type checker.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

async def send_message(self, request: SendMessageRequest) -> SendMessageResponse:
"""Send a message using the A2A v0.2.3 protocol."""
request_id = request['id']
task_id = str(uuid.uuid4())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be generated in the submit_task - so we have the task id in the task object after.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. 👍

Comment on lines 168 to 169
"""Stream messages using Server-Sent Events. Not implemented."""
raise NotImplementedError('message/stream method is not implemented yet.')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""Stream messages using Server-Sent Events. Not implemented."""
raise NotImplementedError('message/stream method is not implemented yet.')
"""Stream messages using Server-Sent Events."""
raise NotImplementedError('message/stream method is not implemented yet.')

physicsrob and others added 15 commits July 7, 2025 21:15
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
…ation

  - Change DataPart.data type from Any to dict[str, Any] per A2A spec
  - Wrap non-dict agent results as {"result": <data>} for consistency
  - Remove DataPart.description field (not in spec)
  - Improve message vs artifact separation:
    - String outputs appear in both messages and artifacts
    - Structured data only appears as artifacts (not duplicated in messages)
  - Update tests to reflect new behavior
  - Update docs to clarify artifact handling
@physicsrob
Copy link
Author

@Kludex Just finished my updates.

Changes:

  1. I believe I addressed all of your first round of feedback (thanks!)
  2. A little bit of misc tidying on my part
  3. Changed the logic for creating for handling agent results. The new logic is that string results are treated as messages AND also result in a TextPart artifact, whereas structured results are treated as artifacts only and do not show up in the message history.

@physicsrob physicsrob changed the title [DRAFT] Upgrade a2a v0.2.3 Upgrade a2a to spec v0.2.3 Jul 8, 2025
Copy link
Member

@Kludex Kludex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm reverting the test name directory change. Please avoid changes outside the scope of the PR.

Comment on lines 121 to 124
elif a2a_request['method'] == 'message/stream':
raise NotImplementedError(
'message/stream method is not implemented yet. Streaming support will be added in a future update.'
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already handle all the non supported methods a bit down here, so I'll drop this for now.

) -> SendMessageResponse:
"""Send a message using the A2A protocol.
Returns a JSON-RPC response containing either a result (Task | Message) or an error.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand by the spec is possible to return both, but we always return Task.

Comment on lines 276 to 277
data: str
"""The base64 encoded data."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's bytes now.

"""Update the state of a task. Appends artifacts and messages, if specified."""

@abstractmethod
async def update_context(self, context_id: str, context: Any) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any is usually a sign that something is wrong.

We can make storage to be generic on context.

Comment on lines 125 to 128
if task is None:
raise ValueError(f'Task {params["id"]} not found')
if 'context_id' not in task:
raise ValueError('Task must have a context_id')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A context_id is always provided in a task - by type definition.

Comment on lines -121 to -122
# TODO(Marcelo): We need to have a way to communicate when the task is set to `input-required`. Maybe
# a custom `output_type` with a `more_info_required` field, or something like that.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You removed my TODO, but I don't think we solve it?

@Kludex Kludex requested a review from Copilot July 8, 2025 07:28
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR upgrades the A2A implementation from protocol v0.1 to v0.2.3 to support conversation continuity via a new context_id, dual-purpose storage for protocol tasks and agent context, and updated schema and endpoints to match the v0.2.3 specification.

  • Introduce context_id in place of session_id and rename endpoint tasks/sendmessage/send
  • Implement dual storage API with get_context()/update_context() alongside task storage
  • Enhance schema with new task states (rejected, auth-required), named artifacts (artifact_id), part discriminators (kind), and message identifiers

Reviewed Changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_a2a.py Updated tests to use send_message, kind, message_id, context_id, and validate JSON schema metadata
pydantic_ai_slim/pydantic_ai/agent.py Swapped import of ProviderAgentProvider and updated to_a2a() parameter type
pydantic_ai_slim/pydantic_ai/_a2a.py Overhauled AgentWorker to load/update context, convert messages and artifacts under new schema
fasta2a/fasta2a/worker.py Minor signature rename in abstract build_message_history
fasta2a/fasta2a/task_manager.py Replaced send_task* with send_message/stream_message, adapted parameters to v0.2.3
fasta2a/fasta2a/storage.py Added update_context()/get_context(), switched storage to track context_id and new message/artifact lists
fasta2a/fasta2a/schema.py Updated TypedDicts: added kind, context_id, message_id, new security schemes, task states, artifact IDs, etc.
fasta2a/fasta2a/client.py Renamed send_tasksend_message, updated request/response adapters
fasta2a/fasta2a/applications.py Updated agent card schema and route handling for message/send
docs/a2a.md Documented context_id, dual-purpose storage, and new artifact behavior
Comments suppressed due to low confidence (1)

fasta2a/fasta2a/task_manager.py:114

  • [nitpick] The client still uses tasks/get for retrieval but message/send for creation, which may be confusing. For symmetry with message/send, consider renaming tasks/getmessage/get or update documentation to clarify the mixed-use.
    async def send_message(self, request: SendMessageRequest) -> SendMessageResponse:

if task is None:
raise ValueError(f'Task {params["id"]} not found')

# TODO(Marcelo): Should we lock `run_task` on the `context_id`?
Copy link
Preview

Copilot AI Jul 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent calls to run_task for the same context_id can race when updating context storage. Consider adding a per-context_id lock or semaphore to serialize context updates.

Copilot uses AI. Check for mistakes.

Comment on lines 117 to 118
# Generic parameters are reversed compared to Agent because AgentDepsT has a default
class AgentWorker(Worker, Generic[WorkerOutputT, AgentDepsT]):
Copy link
Preview

Copilot AI Jul 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The generic parameter order is reversed compared to Agent[Deps, Output]. Aligning the parameter order with Agent (i.e. Generic[AgentDepsT, WorkerOutputT]) would reduce confusion in type annotations.

Suggested change
# Generic parameters are reversed compared to Agent because AgentDepsT has a default
class AgentWorker(Worker, Generic[WorkerOutputT, AgentDepsT]):
# Aligning generic parameter order with Agent for consistency
class AgentWorker(Worker, Generic[AgentDepsT, WorkerOutputT]):

Copilot uses AI. Check for mistakes.

else:
# For structured data, create a DataPart
try:
# Try using TypeAdapter for proper serialization
Copy link
Preview

Copilot AI Jul 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The fallback for structured data does not handle Pydantic BaseModel instances explicitly. Consider detecting isinstance(result, BaseModel) and using result.model_dump() (or equivalent) to preserve all fields.

Copilot uses AI. Check for mistakes.

@holtskinner
Copy link

holtskinner commented Jul 8, 2025

NOTE: The A2A Protocol has been updated to v0.2.5, I would recommend adding in the updates between versions 0.2.3 and 0.2.5 https://github.com/a2aproject/A2A/releases

In addition, it could make sense to change FastA2A over to use the official A2A Python SDK under the hood to make it easier to update to newer versions of the protocol.

@Kludex
Copy link
Member

Kludex commented Jul 8, 2025

NOTE: The A2A Protocol has been updated to v0.2.5, I would recommend adding in the updates between versions 0.2.3 and 0.2.5 a2aproject/A2A/releases

In addition, it could make sense to change FastA2A over to use the official A2A Python SDK under the hood to make it easier to update to newer versions of the protocol.

I'll do that.


I'm moving the FastA2A to its own repository.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants